Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an API for editing a group membership's role #9114

Merged
merged 2 commits into from
Nov 27, 2024
Merged

Conversation

seanh
Copy link
Contributor

@seanh seanh commented Nov 23, 2024

Add an API for editing a group member's role:

PATCH /api/groups/{pubid}/members/{userid}
{"roles": ["<new_role>"]}

Testing:

  1. Log in as devdata_admin (http://localhost:5000/login)
  2. Create an API token (http://localhost:5000/account/developer)
  3. Create a group (http://localhost:5000/groups/new)
  4. Log in as a devdata_user and join the group
  5. Use devdata_admin's API token to promote devdata_user to admin: httpx http://localhost:5000/api/groups/{pubid}/members/acct:devdata_user@localhost --method PATCH --headers Authorization 'Bearer {apitoken}' --json '{"roles": ["admin"]}'
  6. Use the "me" alias to demote yourself to a plain member: httpx http://localhost:5000/api/groups/{pubid}/members/me --method PATCH --headers Authorization 'Bearer {apitoken}' --json '{"roles": ["admin"]}'
  7. Now that you're just a plain member if you try to promote yourself again or try to change devdata_user's role you'll get 404s.
  8. If you try to use an invalid role you'll get a validation error, for example: httpx http://localhost:5000/api/groups/{pubid}/members/me --method PATCH --headers Authorization 'Bearer {apitoken}' --json '{"roles": ["INVALID"]}'

@seanh seanh requested a review from marcospri November 23, 2024 04:52
Comment on lines +5 to +12
def asdict(self):
return {
"authority": self.membership.group.authority,
"userid": self.membership.user.userid,
"username": self.membership.user.username,
"display_name": self.membership.user.display_name,
"roles": self.membership.roles,
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is compatible with the user objects currently returned by the get-group-members API just with roles added. (It was previously thought of as a list of users, but now we're thinking of it as a list of memberships.)

Comment on lines +24 to +27
return [
GroupMembershipJSONPresenter(membership).asdict()
for membership in context.group.memberships
]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get-group-members API now uses the new GroupMembershipJSONPresenter instead of UserJSONPresenter. It returns the same dicts as UserJSONPresenter does but with roles added. This means that this PR also adds roles to the get-group-members API responses.

@@ -28,7 +36,6 @@ def list_members(context: GroupContext, _request):
permission=Permission.Group.MEMBER_REMOVE,
)
def remove_member(context: GroupMembershipContext, request):
"""Remove a member from a group."""
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing these pointless docstrings.

Comment on lines +77 to +82
if not request.has_permission(
Permission.Group.MEMBER_EDIT,
EditGroupMembershipContext(
context.group, context.user, context.membership, new_roles
),
):
raise HTTPNotFound()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no permission in the @api_config() on this view. The reason is that whether or not you have permission to change a role depends on the new role that you want to change it to (only owners can change someone's role to owner or admin, admins can change someone's role to moderator or member, etc).

The new role comes from the request JSON body and isn't available until that JSON body has been parsed and validated, and that happens in the view. So we can't use Pyramid's permission thing where it calls the security policy before calling the view.

Instead we let Pyramid call the view regardless, and then the view itself constructs a context object and calls request.has_permission().

We do need the permissions logic to be implemented in the centralized permissions system because in future another view is going to have to check MEMBER_EDIT permissions as well, so we couldn't just code the logic directly in this view. Also permissions logic just belongs in the permissions system.

):
raise HTTPNotFound()

context.membership.roles = new_roles
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need for a service to carry out the business logic, it's just an assignment.

old_roles,
)

return GroupMembershipJSONPresenter(context.membership).asdict()
Copy link
Contributor Author

@seanh seanh Nov 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The edit_member() and list_members() views both use GroupMembershipJSONPresenter so they both return groups in the same format.

If we ever add a get_member() view it can use the same presenter as well.

add_member() could also use it as well but that might be a breaking change (currently it returns an HTTP 204 with no body).

@seanh seanh force-pushed the add-edit-membership-api- branch from 0c02bd7 to d0791b7 Compare November 23, 2024 05:07
Comment on lines +25 to +34
assert res.json == [
{
"authority": membership.group.authority,
"userid": membership.user.userid,
"username": membership.user.username,
"display_name": membership.user.display_name,
"roles": membership.roles,
}
for membership in group.memberships
]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made this test assertion more specific so I can see exactly what changes I'm making to the get-group-members API (adding the roles)

Comment on lines +53 to +62
assert res.json == [
{
"authority": membership.group.authority,
"userid": membership.user.userid,
"username": membership.user.username,
"display_name": membership.user.display_name,
"roles": membership.roles,
}
for membership in group.memberships
]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

@seanh seanh force-pushed the add-edit-membership-api- branch 2 times, most recently from 79efb82 to 702c89e Compare November 23, 2024 06:26
@seanh seanh force-pushed the add-edit-membership-api branch from a0a645d to 078fb8c Compare November 26, 2024 14:04
Base automatically changed from add-edit-membership-api to main November 26, 2024 14:28
@seanh seanh force-pushed the add-edit-membership-api- branch from 702c89e to b30f3cd Compare November 26, 2024 14:34
old_roles = context.membership.roles
new_roles = context.new_roles

def get_authenticated_users_roles():
Copy link
Member

@marcospri marcospri Nov 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and the equivalent get_authenticated_users_membership could be eventually refactored and be a helper method on identity.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's a good idea. I'll add a card for this to Misha's onboarding project

return True

if GroupMembershipRoles.ADMIN in authenticated_users_roles:
# Admins can change their own role to anything but admin.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but owner?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, fixed 👍

if not authenticated_users_roles:
return False

if identity.user.userid == context.user.userid:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are all an user changing it own roles

@@ -133,16 +133,3 @@ def update(context: GroupContext, request):
group = group_update_service.update(context.group, **appstruct)

return GroupJSONPresenter(group, request).asdict(expand=["organization", "scopes"])


# @TODO This is a duplication of code in h.views.api — move to a util module
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 years in the making.

@seanh seanh merged commit b64c396 into main Nov 27, 2024
9 checks passed
@seanh seanh deleted the add-edit-membership-api- branch November 27, 2024 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants